{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<center>\n",
    "<img src=\"../../img/ods_stickers.jpg\">\n",
    "## Открытый курс по машинному обучению\n",
    "<center>Автор материала: Артём Асмоловский"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Скрапинг с использованием библиотеки BeautifulSoup и регулярных выражений."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Прежде чем вы вы будете создавать собственные скраперы, помните, что случайным образом вы можете оказаться на страницах, содержащих информацию, непредназначенную для публичного использования. Будьте внимательны, когда выбираете ресурс, с которого извлекаете информацию. Если вы скачаете информацию по всем пользователям, скажем, вконтакте, то, как минимум, получите предупреждение прекратить заниматься подобными делами."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Веб-скрапинг представляет собой автоматизированняй сбор данных с различных веб-ресурсов. Безусловно, если определённый ресурс имеет свой API, удобнее воспользоваться им, потому как данные там уже удобно структурированы, но тот же твитер ограничивает количество обращений до примерно 150/час."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Именно в такие моменты на помощь и приходит веб-скрапинг. Пусть данные и имеют несколько усложнённую структуру, зато одновременно обрабатывать можно множество сайтов, да и написать собственного бота всегда приятнее."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#Устанавливаем BS\n",
    "!pip install beautifulsoup4"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "К слову, BeautifulSoup - одна из наиболее популярных и удобных библиотек для парсинга html/xml-разметки."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import csv\n",
    "import re\n",
    "import sys\n",
    "import warnings\n",
    "from urllib.error import HTTPError\n",
    "from urllib.request import urlopen\n",
    "\n",
    "import pandas as pd\n",
    "#Импортируем необходимые библиотеки\n",
    "from bs4 import BeautifulSoup\n",
    "\n",
    "warnings.filterwarnings('ignore')\n",
    "#urlopen необходим для открытия веб-страниц"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В качестве веб-сервиса, с которого будем скрапить данные, возьмём англоязычную википедию. А задачу сформулируем следующим образом: перейдём на страничку Стивена Кинга и итеративно будем переходить по всем ссылкам (ссылающимся только на страницы википедии), которые указывают на актёров и актрис и заполним небольшой датасет, извлекая интересную информацию."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#Считываем необходимую страницу и передаём в конструктор BS\n",
    "first_page = urlopen('https://en.wikipedia.org/wiki/Stephen_King')\n",
    "bs = BeautifulSoup(first_page)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "На данный момент мы создали BS-объект, который содержит urlopen со множеством html-тегов и соответствующую им информацию, которая находится на заданной странице. С помощью метода get_text() можно очистить объект от всех тегов. Однако сделав это сейчас, мы потеряем важную информацию, например, что является текстом, а что - названием изображения внутри статьи."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#Информация представлена следующим образом\n",
    "bs.get_text()[17000:17250]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Теперь важный момент. У нас есть объект, содержащий html-разметку, мы хотит из каждой страницы извлекать Имя и Фамилию, а так же возраст. Чтобы понять, где вся эта информация расположена, необходимо взглянуть на страницу википедии через \"режим инспектирования\" (перейти можно, нажав f12 в браузере)."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<img src=\"../../img/scraping1.png\">"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "BS имеет два метода, которые извлекают информацию по тегам: find() и findAll(). В конструктор им передаётся название интересующего тега, и словарь атрибутов, по которому необходимо извлечь объект. В нашем случае тег - это 'span', а словарь атрибутов - {'class' : 'fn'}. Различия этих методов в том, что первый остановится на первом найденном теге 'span' и соответствущих ему атрибутах, а второй пройдётся по всем на данной странице. Соответственно, первый метод возвращает просто BS-объект, а второй - BS-список, состоящий из BS-объектов. Поэтому, если есть необходимость очистить объект от тегов с помощью get_text(), но при этом до этого был использован метод findAll(), из полученного списка необходимо извлечь объект по индексу, как из простого питоновского списка."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "bs.find('span', {'class' : 'fn'}).get_text()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "bs.findAll('span', {'class' : 'fn'})[0].get_text()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Если методу find передать несуществующий тег, то вернётся пустой объект. Однако если попытаться дальше с ним работать, вызывая дополнительные методы, как у BS-объекта, то будет сгенерировано исключение AttributeError. Поэтому перед запуском скрапера стоит убедиться, что данное исключение перехватывается."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "try:\n",
    "    bs.error_tag.error\n",
    "except AttributeError as e:\n",
    "    print('caught an error')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Теперь создадим список статей актёров и актрис, на которых имеются ссылки с исследуемой страницы. Проинспектируем блок основного текста, он хранится внутри тега 'div' с атрибутами {'id' : 'bodycontent'}. Затем проинспектируем любую гиперссылку внутри этого блока, тег в этом случае: 'a', атрибуты: {'href' : 'ссылка'}. Как раз здесь и понадобятся регулярные выражения, чтобы ограничить свободу скрапера только вики.\n",
    "Возвращаемый BS-объект может иметь несколько атрибутов, чтобы получить список всех имеющихся, достаточно вызвать .attrs. Применительно к ссылкам, они хранятся в атрибуте с названием 'href'.\n",
    "Если посмотреть на список вики-ссылок, то можно заметить, что все они начинаются с /wiki/, поэтому регулярка в данном случае будет достаточно простой, за тем исключением, что некоторые ссылки могут указывать на изображения (в таких ссылках встречается двоеточие '/wiki/File:Stephen_King,_Comicon.jpg'), которые необходимо игнорировать."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "links = []\n",
    "for link in bs.find('div', {'id' : 'bodyContent'}).findAll('a', href = re.compile('\\/wiki\\/((?!:).)*$')):\n",
    "    links.append(link.attrs['href'])\n",
    "\n",
    "links[:10]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Остаётся создать метод, который будет рукурсивно обрабатывать каждую ссылку, и, если, в поле Occupation встретится actor или actress, то парсить необходимую информацию."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#Сюда будем вносить посещённые ссылки, чтобы не оказаться на одной и той же странице несколько раз\n",
    "links = set()\n",
    "def make_scraping(Url):\n",
    "        \n",
    "    global links\n",
    "    name = None\n",
    "    surname = None\n",
    "    birthdate = None\n",
    "    films = None\n",
    "        \n",
    "    html = urlopen('https://en.wikipedia.org' + Url)\n",
    "    bs = BeautifulSoup(html)\n",
    "        \n",
    "    #Если на заданной странице нет поля \"Occupation\", то эта страница, скорее всего, не про человека,\n",
    "    #и будет сгенерировано исключение AttribiteError. Просто перехватим его и пропустим страницу.\n",
    "    try:\n",
    "        #Ищем поле Occupation. Как правило, у знаменитых людей в этом поле несколько объектов, \n",
    "        #иногда и певец может оказаться актёром. Более того, если искомый нами \"actor\" или \"actress\" окажутся \n",
    "        #первыми в этом списке, то будут начинаться с заглавной буквы, поэтому приведём все объекты списка \n",
    "        #к нижнему регистру.\n",
    "        occupation = bs.find('td', {'class' : 'role'}).get_text().lower().split('\\n')\n",
    "        \n",
    "        if (('actor' in occupation) or\n",
    "            ('actress' in occupation)):\n",
    "                \n",
    "            #На всякий случай, если мы вдруг оказались на заготовке статьи об актёре, то некоторые поля могут\n",
    "            #отсутствовать, поэтому введём дополнительную обработку исключений.\n",
    "            try:\n",
    "                name_obj = bs.find('span', {'class' : 'fn'}).get_text()\n",
    "                name = name_obj.split(' ')[0]\n",
    "                surname = name_obj.split(' ')[1]\n",
    "            except AttributeError as e:\n",
    "                name = None\n",
    "                surname = None\n",
    "            try:\n",
    "                age = re.findall('\\d+', bs.find('span', {'class' : 'ForceAgeToShow'}).get_text())[0]\n",
    "            except AttributeError as e:\n",
    "                age = None\n",
    "            #Укажите свой репозиторий\n",
    "            with open('actors.csv', 'a', newline='') as file:\n",
    "                writer = csv.writer(file, delimiter = ',')\n",
    "                writer.writerow([name, surname, age])\n",
    "            \n",
    "            #Извлечём все ссылки из блока основного текста. В данном случае регулярное выражение чуть-чуть сложнее.\n",
    "            #Если гиперссылка ведёт, например, на фотографию, то в ней присутствует двоеточие. Просто укажем, что\n",
    "            #двоеточий быть не должно.\n",
    "            for link in bs.find('div', {'id' : 'bodyContent'}).findAll('a', href = re.compile('\\/wiki\\/((?!:).)*$')):\n",
    "                if link.attrs['href'] not in links:\n",
    "                    links.add(link.attrs['href'])\n",
    "                    try:\n",
    "                        make_scraping(link.attrs['href'])\n",
    "                    except:\n",
    "                        print('an exception' in link.attrs['href'])\n",
    "                        \n",
    "    except AttributeError as e:\n",
    "        pass            "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "make_scraping('/wiki/Stephen_King')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Если строить скрапер в надежде, что за достаточно длительное количество времени он соберёт необходимый объём информации, то стоит иметь в виду, что стандартные питоновские ограничения относительно рекурссии составляют 1000 вызовов. Соответственно, для изменения этого значения, необходимо импортировать билиотеку sys, а затем вызвать метод sys.setrecursionlimit(), передав в качестве аргумента необходимое количество вызовов."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В процессе скрапинга могут возникать различные ошибки. Наиболее часто встречающиеся: AttributeError и HTTPError из билиотеки urllib.error, которые мы обработали. Однако если оставлять что-то подобное на длительнео время, я бы советовал дополнительно перехватывать базовый класс Exception."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Итак, какое-то время паук поперемещался по сайтам и что-то извлекал, стоит оценить результаты его работы."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#Укажите ваш репозиторий\n",
    "df = pd.read_csv('actors.csv', names = ['Name', 'Surname', 'Age'])\n",
    "df.head(10)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Как видим, парсер действительно честно работал. Правда в поле Age у Alicia Keys стоит None, следует разобраться почему."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<img src=\"../../img/scraping2.png\">"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Дело в том, что возраст не находится внутри специального тега ForceAgeToShow. То есть скрапер отработал верно, он честно не нашёл тег и заменил возраст на None.\n",
    "Забавно, но когда я решил проверить Баста Раймса на принадлежность к актёрской профессии, то такое поле действительно указано на википедии."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<img src=\"../../img/scraping3.png\">"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В заключение. Создать собственный парсер, извлекающий информацию плоским списком, несложно. Гораздо труднее продумывать архитектуру вложенной извлекаемой информации. Так, в данном примере можно было бы дополнительно извлекать фильмы, к которым каждый рассматриваемый актёр имеет отношение. Переходя на новую ссылку со страницы актёра, если в первом абзаце присутствует слово \"film\", то статья в большинстве случаев о фильме (по собственным наблюдениям). Однако в данном случае труднее извлекать эти названия с n-ого уровня рекурссии."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Что стоит почитать"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "http://pythonscraping.com/ - Ryan Mitchell - Web Scraping with Python: Collecting Data from the Modern Web. Эта книга есть и на русском языке."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "https://www.crummy.com/software/BeautifulSoup/bs4/doc/ - документация по библиотеке BeautifulSoup."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "https://realpython.com/python-web-scraping-practical-introduction/ - интересная ветка с примерами."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.6.5"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
